泛化在程序里，体现为一套有继承关系的对象，而在数据库里体现为若干张表。所以，泛化的编码主要解决的问题就是，怎么把内存中的对象和数据库表里的数据进行相互转换。这个问题解决了，其他部分就和常规的面向对象编程没有什么区别了。


同一个泛化结构，在内存中的对象布局是一样的，但根据不同的数据库设计策略，数据库里的表结构却是不一样的，上节课我们讲过主要有三种。这就造成了泛化关系的持久化问题，比关联关系的持久化要更加复杂一些。


你应该已经想到了，这里说的内存和数据库数据的相互转换问题，是在仓库（repository）里解决的。或者说，仓库屏蔽了不同的表结构的差别，我会结合工时项和客户的例子带你体会这一点。

## 为领域模型编码

我们首先为工时项以及它的子类编写领域层代码。之前说过，我们要养成边看领域模型，边写代码的习惯，所以先回顾一下领域模型。

<img src="https://wkq-img.oss-cn-chengdu.aliyuncs.com/20241211235325.png"/>


本来，传统上的泛化既可以用继承来实现，也可以用不同的属性值来实现。不过根据 DDD 的思路，我们在领域建模的时候，已经有意识地考虑了领域模型和程序实现一致性，所以，对于上图里的泛化，我们直接用继承来实现就可以了。



那么，在程序设计上，应该把工时项建成一个父类吗？

如果用 Java 的话，我们发现无法做到这一点。为了说明这个问题，我们画一下设计模型图。

<img src="https://wkq-img.oss-cn-chengdu.aliyuncs.com/20241211235438.png"/>

上面就是设计模型的类图（后面简称为设计图）。


我们首先回味一下设计图和领域模型图的一个区别。领域模型图里只有带实线的空心箭头，而设计图里有实线和虚线两种空心箭头。设计图里的实线箭头代表继承，也就是 Java 里的 extends；虚线箭头代表对接口的实现，也就是 Java 里的 implements。使用继承还是实现，都是代码设计中的考虑，不是业务概念，因此在领域模型图里不需要区分，所以在领域模型图里只需要表示“泛化”的实线箭头。


好，我们继续讨论是否可以用类的继承来实现这个泛化体系的问题。本来，如果没有 SubProject （子项目）的话，可以让 EffortItem（工时项）成为 AggregateRoot（聚合根）的子类，让 Project（项目）和 CommonEffortItem（普通工时项）继承 EffortItem。EffortItem 类里面有 EffortItemId（工时项 ID）属性。


但是，如果让 SubProject 也继承 EffortItem 类的话，SubProject 就成了聚合根，问题在于 SubProject 并不是聚合根。所以 SubProject 只能继承 AuditableEntity（可审计的实体）。由于 Java 不支持多继承，我们就没有别的选择了，只能把 EffortItem 设计成一个接口，而公共的 EffortItemId 属性只能放在各个子类里了。


<RedSpan>AuditableEntity 和 AggregateRoot 是技术实现时候的考虑，没有业务概念，所以在领域模型图里并不存在</RedSpan>。



接下来，我们再复习一下有关设计图的其他几个知识点。


设计图里用了英文，目的是接近代码实现；领域模型图里用中文，目的是便于和领域专家交流。要根据词汇表进行中英文的转换。

在设计图里有权限修饰符，加号（+）代表 public，减号（-）代表 private，井号（#）代表 protected，波浪号（~）代表包级私有。而领域模型图里所有属性都是业务可感知的概念，都可以认为是公共的，所以不需要写权限修饰符。


在传统的面向对象方法学里，理论上要根据领域模型图绘制详细的设计图，再进行编码。但在敏捷的实践中，只需要在必要的时候才画部分设计图，多数情况下直接按照领域模型图写代码就行了。我们目前这个设计图只能算是一个示意图。其中省略了 getter 和 setter，也省略了所在的包图。


那么，有了设计，领域层的代码实现就比较简单了。 EffortItem 接口的代码是这样。
```java

package top.wwkq.ddddemo.domain.effortmng.effortitem;

/**
 * 工时项接口，无法用继承实现， Java 不支持多继承
 */
public interface EffortItem {
}

```
Project 类的部分代码是后面这样。

```java
package top.wwkq.ddddemo.domain.projectmng;

import top.wwkq.ddddemo.common.framework.domain.AggregateRoot;
import top.wwkq.ddddemo.domain.effortmng.effortitem.EffortItem;
import top.wwkq.ddddemo.domain.projectmng.enums.ProjectStatus;

import java.time.LocalDateTime;
import java.time.Period;
import java.util.*;

public class Project extends AggregateRoot implements EffortItem {
    private final Long tenantId;// 租户 Id
    private final Long effortItemId; // 工时项 ID
    private String name; // 项目名称
    private Period period; // 起止时间段
    private ProjectStatus status;// 项目状态
    private Boolean clientProject; // 是否客户项目
    private Boolean shouldAssignMember; // 是否要分配人员
    private EffortGraularity effortGraularity;// 工时粒度

    // 项目经理
    private final Map<Period,ProjectMng> mngs = new HashMap<>();

    // 子项目
    private final Collection<SubProject> subProjects = new ArrayList<>();


    public Project(LocalDateTime createdAt, Long createdBy, Long tenantId, Long effortItemId) {
        super(createdAt, createdBy);
        this.tenantId = tenantId;
        this.effortItemId = effortItemId;
    }

    @Override
    public Long getEffortItemId() {
        return 0L;
    }

    @Override
    public String getName() {
        return "";
    }
}

```





我们看到，Project 继承了 AggregateRoot， 同时实现了 EffortItem 接口。EffortItem 的其他几个实现类也是类似的，就不一一展开了。

## 查询工时项
对于工时项，一个最重要的功能是给定一个员工，查询这个员工可以报工时的工时项列表。这个功能的入口在工时项的应用服务 EffortService 里。


我们先设计一下这个功能的返回值类型。对于报工时的需求，前端只需要得到每个工时项的 ID 和名称就可以了。我们先编写 EffortItemDTO 来存放这两个属性。

```java
package top.wwkq.ddddemo.application.effortmng;

/**
 * 对于报工时的需求，前端只需要得到每个工时项的 ID 和名称就可以了。我们先编写 EffortItemDTO 来存放这两个属性。
 */
public class EffortItemDto {
    private Long effortItemId;
    private String name;

    public EffortItemDto(Long effortItemId, String name) {
        this.effortItemId = effortItemId;
        this.name = name;
    }

    public EffortItemDto() {
    }

    public Long getEffortItemId() {
        return effortItemId;
    }

    public EffortItemDto setEffortItemId(Long effortItemId) {
        this.effortItemId = effortItemId;
        return this;
    }

    public String getName() {
        return name;
    }

    public EffortItemDto setName(String name) {
        this.name = name;
        return this;
    }
}

```
然后，把 DTO 组织在一起，成为返回值类型的 AvailableEffortItems。

```java
package chapter26.unjuanable.application.effortmng;
// imports ...

public class AvailableEffortItems {
    List<EffortItemDTO> assignments = new ArrayList<>();
    List<EffortItemDTO> subProjects = new ArrayList<>();
    List<EffortItemDTO> commonProjects = new ArrayList<>();
    List<EffortItemDTO> commonEffortItems = new ArrayList<>();

    void addItem(ItemType type, Long effortItemId, String name) {
        switch (type) {
            case ASSIGNED_PROJECT:
                assignments.add(new EffortItemDTO(effortItemId, name));
                break;
            case COMMON_PROJECT:
                commoanProjects.add(new EffortItemDTO(effortItemId, name));
                break;
            case SUB_PROJECT:
                subProjects.add(new EffortItemDTO(effortItemId, name));
                break;
            case COMMON:
                commontEffortItems.add(new EffortItemDTO(effortItemId, name));
                break;
        }
    }

    // getters ...

    public enum ItemType {
        ASSIGNED_PROJECT, COMMON_PROJECT, SUB_PROJECT, COMMON
    }
}

```

了便于前端显式，返回值把工时项分成了分配的项目（assignments）、通用项目（commonProjects）、子项目（subProjects）、普通工时项（commonEffortItems） 4 个列表。


编写了返回值类型，就可以编写查询工时项功能了。下面是应用服务的代码。


```java
package chapter26.unjuanable.application.effortmng;

// imports ...

@Service
public class EffortService {
    // 项目仓库
    private final ProjectRepository projectRepository;
    // 普通工时项仓库
    private final CommonEffortItemRepository commonEffortItemRepository;

    @Autowired
    public EffortService(ProjectRepository projectRepository
            , CommonEffortItemRepository commonEffortItemRepository) {
        // 仓库的依赖注入 ...
    }

    // 查找员工可用的工时项
    public AvailableEffortItems findAvailableEffortItems(Long empId) {
        Collection<Project> assignments
                = projectRepository.findAssignmentsByEmpId(empId);
        Collection<Project> commonProjects
                = projectRepository.findCommonProjects();
        Collection<CommonEffortItem> commonEffortItems
                = commonEffortItemRepository.findAll();

        var result = new AvailableEffortItems();

        assignments.forEach( p ->
                result.addItem(ASSIGNED_PROJECT,p.getEffortItemId(), p.getName()));

        commonProjects.forEach( p ->
                result.addItem(COMMON_PROJECT, p.getEffortItemId(), p.getName()));

        commonEffortItems.forEach( i ->
                result.addItem(COMMON_ITEM, i.getEffortItemId(), i.getName()));

        return result;
    }
}

```

这个服务的算法是分别从数据库进行三次查询，查出分配给这个员工的项目、不需要分配人员的项目（也就是通用项目）以及普通工时项。然后分别利用它们的工时项 ID 和名称，构造查询结果。

### 查询工时项的改进


这段代码的问题是，从 29 行到 36 行非常相似，似乎可以抽取出来。那么我们就试着抽一下。变成了下面的样子。

```java
package chapter26.unjuanable.application.effortmng;

// imports ...

@Service
public class EffortService {
    // 仓库和构造器没有变化 ...

    public AvailableEffortItems findAvailableEffortItems(Long empId) {
        Collection<Project> assignments
                = projectRepository.findAssignmentsByEmpId(empId);
        Collection<Project> commonProjects
                = projectRepository.findCommonProjects();
        Collection<CommonEffortItem> commonEffortItems
                = commonEffortItemRepository.findAll();

        var result = new AvailableEffortItems();

        //使用抽取出的方法
        appendResult(ASSIGNED_PROJECT, assignments, result);
        appendResult(COMMON_PROJECT, commonProjects, result);

        //由于类型不匹配，不能使用抽取的方法
        commonEffortItems.forEach( i ->
                result.addItem(COMMON_ITEM, i.getEffortItemId(), i.getName()));

        return result;
    }

    // 抽取出的公共方法
    private void appendResult(AvailableEffortItems.ItemType type
            , Collection<Project> items
            , AvailableEffortItems result) {
        items.forEach(p ->
                result.addItem(type, p.getEffortItemId(), p.getName()));
    }
}
```

只有存放项目的 Collection，也就是 assignments 和 commonProjects ，才能使用抽出的公共方法 appendResult()，而 commonEffortItems 则无法使用。这是因为它的类型不是 Collection，而是 Collection，不符合 appendResult() 的签名。


那么，怎么让 commonEffortItems 也能使用这个公共的方法呢？

由于 Project 和 CommonEffortItem 都是 EffortItem 的子类，所以我们可以利用泛型的技巧来解决。


```java
package chapter26.unjuanable.application.effortmng;

// imports ...

@Service
public class EffortService {
    // 仓库和构造器没有变化 ...

    public AvailableEffortItems findAvailableEffortItems(Long empId) {
        Collection<Project> assignments
                = projectRepository.findAssignmentsByEmpId(empId);
        Collection<Project> commonProjects
                = projectRepository.findCommonProjects();
        Collection<CommonEffortItem> commonEffortItems
                = commonEffortItemRepository.findAll();

        var result = new AvailableEffortItems();

        appendResult(ASSIGNED_PROJECT, assignments, result);
        appendResult(COMMON_PROJECT, commonProjects, result);

        //commonEffortItems 也可以使用公共方法了
        appendResult(COMMON_ITEM, commonEffortItems, result);

        return result;
    }

    private void appendResult(AvailableEffortItems.ItemType type
            , Collection<? extends EffortItem> items //使用通配符
            , AvailableEffortItems result) {
        items.forEach(p ->
                result.addItem(type, p.getEffortItemId(), p.getName()));
    }
}
```

我们在 29 行使用类型通配符，这样就可以像 23 行那样使用公共方法了。如果你不是 Java 背景的话，可以忽略这个技巧。只需要知道由于接口 EffortItem 的存在，我们可以更方便地抽取公共逻辑就可以了。


最后，可以利用“内联”的重构手法，去除多余的局部变量定义，把代码再简化一点。


```java
package chapter26.unjuanable.application.effortmng;
//imports ...

@Service
public class EffortService {
    // 仓库和构造器没有变化 ...

    public AvailableEffortItems findAvailableEffortItems(Long empId) {

        var result = new AvailableEffortItems();

        // 用"内联"重构，去除多余的局部变量
        appendResult(ASSIGNED_PROJECT
                , projectRepository.findAssignmentsByEmpId(empId)
                , result);

        appendResult(COMMON_PROJECT
                , projectRepository.findCommonProjects()
                , result);

        appendResult(COMMON_ITEM
                , commonEffortItemRepository.findAll()
                , result);

        return result;
    }

    // 其他部分不变 ...
}
```

这样，我们就完成了关于工时项的例子。正如我们在上节课讲的，这种实现方式要查询三遍数据库，在性能方面还有改进余地。我们会在第三个迭代解决。

## 为每个类一个表编码

在工时项的例子里，我们采用的数据库设计策略是“每个子类一个表”。这种策略下，聚合的持久化和之前的做法变化并不大，所以仓库里有关增加和修改的代码我们就没有列出来了。

每个类一个表”，也就是为父类也建表的情况下，仓库的逻辑会更复杂一些。我们用上节课举过的个人客户和企业客户的例子来说明一下。


先回顾一下领域模型和表结构。

<img src="https://wkq-img.oss-cn-chengdu.aliyuncs.com/20241212002943.png"/>


这里为父类和各个子类各建了一张表，并且采用“共享主键”的策略。

下面看看领域对象的代码。首先是父类 Client。
```java
package chapter26.partysample.domain.client;
// imports ...

public abstract class Client extends AggregateRoot {
    private Long id;
    private Address address;

    // constructors ...

    public abstract String getClientType();

    // other getters and setters ...

}
```
关于这个父类，有三个要点需要留意。

首先，我们从第 4 行可以看到，Client（客户）类是一个抽象类，因为一个抽象的客户是不能实例化的，只有实例化具体的个人或企业客户才有意义。此外，Client 是聚合根的子类，这就意味着它的所有子类也是聚合根。


第二，从第 6 行看到，Address 是一个值对象。而对应的表是把各个地址的属性打散的，也就是我们之前提过的“嵌入”的方法。我们之前也说过，内存中的对象和数据库表的布局不一致的情况，称为“阻抗不匹配”，要通过仓库（repository）来进行转换。

第三，在数据库表里有一个 client_type 来区分是哪个子类。这个字段有两个可选值：“P” 代表个人客户，“C” 代表企业客户。那么这两个值在程序里定义在哪里呢？


一种方法是在 Client 父类里用枚举或字符串常量来定义。但这样的话，如果将来又多一个子类，就要改变 Client 或枚举的定义，这就违反了“开闭原则”。所以我们现在在父类里只定义了一个抽象方法，也就是第 10 行的 getClientType()，由子类去实现。

我们继续看子类 CorporateClient （企业客户）的代码。

```java
package chapter26.partysample.domain.corporateclient;
// import ...

public class CorporateClient extends Client {
    public static final String CLIENT_TYPE_CORPORATE = "C";

    private String name;
    private String taxNum;

    // constructors, setters and getters ...

    @Override
    public String getClientType() {
        return CLIENT_TYPE_CORPORATE;
    }
}
```

这个子类里包含企业客户独有的字段。另外，我们用一个常量写出了企业客户的 clientType 值，并通过 getClientType() 返回，这个常量在后面还会用到。

个人客户（PersonalClient）子类的实现方法也类似，就不列出来了。

下面我们重点看仓库的代码。CorporateClient 和 PersonalClient 各有一个对应的仓库。我们只看 CorporateClient 的仓库就可以了。





```java
package chapter26.partysample.adapter.driven.persistence;
// imports ...

@Repository
public class CorporateClientRepositoryJdbc
       implements CorporateClientRepository {

    final ClientDao clientDao;
    final CorporateClientDao corporateClientDao;

    // 用构造器注入 DAO
    @Autowired
    public CorporateClientRepositoryJdbc(ClientDao clientDao
            , CorporateClientDao corporateClientDao) {
        this.clientDao = clientDao;
        this.corporateClientDao = corporateClientDao;
    }

    @Override
    public boolean save(CorporateClient corporateClient) {
        switch (corporateClient.getChangingStatus()) {
            case NEW:
                addCorporateClient(corporateClient);
                break;
            case UPDATED:
                if (!updateCorporateClient(corporateClient)) {
                    return false;
                }
                break;
        }
        return true;
    }

    private void addCorporateClient(CorporateClient client) {
        clientDao.insert(client);
        corporateClientDao.insert(client);
    }

    private boolean updateCorporateClient(CorporateClient client) {
        if (clientDao.update(client)) {
            corporateClientDao.update(client);
            return true;
        } else {
            return false;
        }
    }


    @Override
    public Optional<CorporateClient> findById(Long id) {
        CorporateClient client = corporateClientDao.selectById(id);
        return Optional.ofNullable(client);
    }
}
```

先看一下第 8 行和 9 行。有没发现这里的写法和之前的迭代（例如第 10 节课）不太一样。之前，我们是把 JdbcTemplate 和 SimpleJdbcInsert 直接注入到仓库。现在我们注入的是 DAO，也就是“数据访问对象”。每个 DAO 对应一个表。JdbcTemplate 和 SimpleJdbcInsert 注入到了 DAO，由 DAO 直接访问数据库。这种做法能使程序的关注点更加分离。



也有人喜欢把 XxxDAO 命名为 XxxTable，这样更能表明和表的一一对应关系。要是使用 MyBatis 的话，可以按习惯命名为 XxxMappter，前提是规定每个表对应一个 Mapper。如果用 JPA 则没有必要用 DAO 了，因为 DAO 做的事情都被底层框架自动化了。


第 20 行的 save() 方法和之前的做法没有区别，都是根据数据是否有变化，再决定是新增还是修改。

第 34 行 addCorporateClient() 是向数据库新增企业客户，调用 DAO 分别插入 client 和 coperate_client 两个表。这里发生了内存中的一个对象向数据库里两个表的转换。


第 39 行 updateCorporateClient() 用于修改企业客户。首先修改 client 表。这里实际上用了之前学过的乐观锁的判断，没有被别人并发地抢先修改，才继续修改 corporate_client 表。也就是说，在这个泛化体系中，是在父类的表上加乐观锁，同时就把子类也锁住了。这和之前工时项不同。


第 50 行 findById() 是查询，主要逻辑在 DAO 里。

接下来我们就看一下 DAO。首先是 ClientDao。


```java
package chapter26.partysample.adapter.driven.persistence;
// imports ...

@Component
public class ClientDao {
    final JdbcTemplate jdbc;
    final SimpleJdbcInsert insert;

    @Autowired
    public ClientDao(JdbcTemplate jdbc) {
        // 注入 JdbcTemplate, 初始化 SimpleJdbcInsert ...
    }

    public void insert(Client client) {
        Address address = client.getAddress();

        Map<String, Object> parms = Map.of(
                "client_type", client.getClientType()
                , "addr_country", address.getCountry()
                , "addr_province", address.getProvince()
                , "addr_city", address.getCity()
                , "addr_district", address.getDistrict()
                , "addr_detail", address.getDetail()
                , "version", 1L
                , "created_at", client.getCreatedAt()
                , "created_by", client.getCreatedBy()
        );

        Number createdId = insert.executeAndReturnKey(parms);
        forceSet(client, "id", createdId.longValue());
    }

    public boolean update(Client client) {
        Address address = client.getAddress();
        String sql = "update client "
                + " set version = version + 1 "
                + ", addr_country =? "
                + ", addr_province =? "
                + ", addr_city =? "
                + ", addr_district =? "
                + ", addr_detail =? "
                + ", last_updated_at =?"
                + ", last_updated_by =? "
                + " where id = ? and version = ?";

        int affected = jdbc.update(sql
                , address.getCountry()
                , address.getProvince()
                , address.getCity()
                , address.getDistrict()
                , address.getDetail()
                , client.getLastUpdatedAt()
                , client.getLastUpdatedBy()
                , client.getId()
                , client.getVersion());

        return affected == 1;
    }
}
```

这段代码有几个地方可以注意一下。

在 14 行 insert() 方法里，我们可以看到值对象 address 是怎样以内嵌的方式转化成表数据的。在第 29 行插表的过程中取得 id。由于我们采用了共享主键的策略，所以只在这里取一次主键，插 corporate_client 和 personal_client 的时候就直接用这个 id 了。


第 18 行，调用 getClientType()，这里你可以再体会一下之前说的开闭原则。

第 35 行的 update() 方法中，可以看到对乐观锁的实现。


还有一点，实际上，ClientDao 不仅仅会被企业客户的 CorporateClientRepositoryJdbc 调用，也会被个人客户的 PersonalClientRepositoryJdbc 所调用。这说明，分离关注点提高了程序的可复用性。


最后，我们看看用于企业客户表的 CorporateClientDao。


```java
package chapter26.partysample.adapter.driven.persistence;
// imports ...

@Component
public class CorporateClientDao {

    final JdbcTemplate jdbc;
    final SimpleJdbcInsert insert;

    @Autowired
    public CorporateClientDao(JdbcTemplate jdbc) {
        // 注入 JdbcTemplate 并初始化 SimpleJdbcInsert ...
    }

    void insert(CorporateClient client) {
       // 插入 corporate_client 表 ...
    }

    void update(CorporateClient client) {
        // 插入 corporate_client 表 ...
    }

    CorporateClient selectById(Long id) {
        String sql = " select c.version"
                + ", c.addr_country"
                + ", c.addr_province"
                + ", c.addr_city"
                + ", c.addr_district"
                + ", c.addr_detail"
                + ", cc.name"
                + ", cc.tax_num"
                + ", cc.created_at"
                + ", cc.created_by"
                + ", cc.last_update_at"
                + ", cc.last_updated_by  "
                + " from client as c"
                + "   left join corporate_client  as cc"
                + "   on c.id = cc.id "
                + " where c.id = ? and c.client_type = ? ";


        CorporateClient client = jdbc.queryForObject(sql,
                (rs, rowNum) -> {
                    Address address = new Address(
                              rs.getString("addr_country")
                            , rs.getString("addr_province")
                            , rs.getString("addr_city")
                            , rs.getString("addr_district")
                            , rs.getString("addr_detail")
                    );
                    return new CorporateClient(id
                            , rs.getString("name")
                            , rs.getString("tax_num")
                            , address
                            , rs.getTimestamp("created_at").toLocalDateTime()
                            , rs.getLong("created_by")
                            , rs.getLong("last_updated_by")
                            , rs.getTimestamp("last_updated_at").toLocalDateTime());
                },
                id, CLIENT_TYPE_CORPORATE);
        return client;
    }
}
```

这里 23 行 selectById() 方法值得讲一下，用于从数据库里查询出 CorporateClient 对象。


